diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx index 128b84c..a548575 100644 --- a/app/components/draft/QueueSection.tsx +++ b/app/components/draft/QueueSection.tsx @@ -1,6 +1,23 @@ import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { AutodraftSettings } from "~/components/AutodraftSettings"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; interface QueueSectionProps { queue: Array<{ @@ -28,9 +45,78 @@ interface QueueSectionProps { isEnabled: boolean; mode: "next_pick" | "while_on"; }; - onClearQueue: () => void; onRemoveFromQueue: (queueId: string) => void; onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void; + onReorder: (participantIds: string[]) => void; +} + +// Sortable queue item component +function SortableQueueItem({ + item, + index, + participantName, + onRemove, +}: { + item: { id: string; participantId: string }; + index: number; + participantName: string; + onRemove: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: item.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+
+ + + + +
+ {index + 1} +
+

{participantName}

+
+
+ +
+ ); } export function QueueSection({ @@ -41,55 +127,64 @@ export function QueueSection({ teamId, isMyTurn, userAutodraft, - onClearQueue, onRemoveFromQueue, onAutodraftUpdate, + onReorder, }: QueueSectionProps) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = queue.findIndex((item) => item.id === active.id); + const newIndex = queue.findIndex((item) => item.id === over.id); + + const reorderedQueue = arrayMove(queue, oldIndex, newIndex); + const participantIds = reorderedQueue.map((item) => item.participantId); + onReorder(participantIds); + } + }; + return (
-
-

Queue ({queue.length})

- {queue.length > 0 && ( - - )} -
- {/* Queue List */} {queue.length === 0 ? (

Click participants to add to your queue

) : ( -
- {queue.map((item, index) => ( -
-
- {index + 1} -
-

- {availableParticipants.find( + + item.id)} + strategy={verticalListSortingStrategy} + > +

+ {queue.map((item, index) => ( + p.id === item.participantId - )?.name || "Unknown"} -

-
-
- + )?.name || "Unknown" + } + onRemove={() => onRemoveFromQueue(item.id)} + /> + ))}
- ))} -
+ + )} {/* Autodraft Settings */} diff --git a/app/models/draft-queue.ts b/app/models/draft-queue.ts index 841b158..1055c54 100644 --- a/app/models/draft-queue.ts +++ b/app/models/draft-queue.ts @@ -27,6 +27,17 @@ export async function getTeamQueue(teamId: string) { .orderBy(asc(schema.draftQueue.queuePosition)); } +export async function isParticipantInQueue(teamId: string, participantId: string): Promise { + const db = database(); + const existing = await db.query.draftQueue.findFirst({ + where: and( + eq(schema.draftQueue.teamId, teamId), + eq(schema.draftQueue.participantId, participantId) + ), + }); + return !!existing; +} + export async function clearTeamQueue(teamId: string) { const db = database(); await db.delete(schema.draftQueue).where(eq(schema.draftQueue.teamId, teamId)); diff --git a/app/routes/api/__tests__/queue.add.test.ts b/app/routes/api/__tests__/queue.add.test.ts new file mode 100644 index 0000000..c7b3d76 --- /dev/null +++ b/app/routes/api/__tests__/queue.add.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { addToQueue, getTeamQueue, isParticipantInQueue } from '~/models/draft-queue'; + +// Mock the models +vi.mock('~/models/draft-queue', () => ({ + addToQueue: vi.fn(), + getTeamQueue: vi.fn(), + isParticipantInQueue: vi.fn(), +})); + +describe('Queue Add - Duplicate Prevention', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should successfully add a participant to an empty queue', async () => { + const teamId = 'team-1'; + const participantId = 'participant-1'; + const seasonId = 'season-1'; + + // Mock that participant is not in queue + vi.mocked(isParticipantInQueue).mockResolvedValue(false); + + // Mock empty queue + vi.mocked(getTeamQueue).mockResolvedValue([]); + + // Mock successful add + const mockQueueItem = { + id: 'queue-1', + seasonId, + teamId, + participantId, + queuePosition: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + vi.mocked(addToQueue).mockResolvedValue(mockQueueItem); + + // Simulate the logic + const alreadyInQueue = await isParticipantInQueue(teamId, participantId); + expect(alreadyInQueue).toBe(false); + + const currentQueue = await getTeamQueue(teamId); + const queuePosition = currentQueue.length + 1; + + const result = await addToQueue({ + seasonId, + teamId, + participantId, + queuePosition, + }); + + expect(isParticipantInQueue).toHaveBeenCalledWith(teamId, participantId); + expect(getTeamQueue).toHaveBeenCalledWith(teamId); + expect(addToQueue).toHaveBeenCalledWith({ + seasonId, + teamId, + participantId, + queuePosition: 1, + }); + expect(result.participantId).toBe(participantId); + expect(result.queuePosition).toBe(1); + }); + + it('should prevent adding duplicate participant to queue', async () => { + const teamId = 'team-1'; + const participantId = 'participant-1'; + + // Mock that participant is already in queue + vi.mocked(isParticipantInQueue).mockResolvedValue(true); + + const alreadyInQueue = await isParticipantInQueue(teamId, participantId); + + expect(alreadyInQueue).toBe(true); + expect(isParticipantInQueue).toHaveBeenCalledWith(teamId, participantId); + + // Should not call addToQueue if already in queue + expect(addToQueue).not.toHaveBeenCalled(); + }); + + it('should add participant to queue when other participants exist', async () => { + const teamId = 'team-1'; + const newParticipantId = 'participant-3'; + const seasonId = 'season-1'; + + // Mock existing queue with 2 participants + const existingQueue = [ + { + id: 'queue-1', + seasonId, + teamId, + participantId: 'participant-1', + queuePosition: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'queue-2', + seasonId, + teamId, + participantId: 'participant-2', + queuePosition: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + vi.mocked(isParticipantInQueue).mockResolvedValue(false); + vi.mocked(getTeamQueue).mockResolvedValue(existingQueue); + + const mockQueueItem = { + id: 'queue-3', + seasonId, + teamId, + participantId: newParticipantId, + queuePosition: 3, + createdAt: new Date(), + updatedAt: new Date(), + }; + vi.mocked(addToQueue).mockResolvedValue(mockQueueItem); + + // Simulate the logic + const alreadyInQueue = await isParticipantInQueue(teamId, newParticipantId); + expect(alreadyInQueue).toBe(false); + + const currentQueue = await getTeamQueue(teamId); + const queuePosition = currentQueue.length + 1; + + const result = await addToQueue({ + seasonId, + teamId, + participantId: newParticipantId, + queuePosition, + }); + + expect(result.queuePosition).toBe(3); + expect(result.participantId).toBe(newParticipantId); + }); + + it('should prevent adding same participant multiple times in quick succession', async () => { + const teamId = 'team-1'; + const participantId = 'participant-1'; + + // First call - not in queue + vi.mocked(isParticipantInQueue).mockResolvedValueOnce(false); + + const firstCheck = await isParticipantInQueue(teamId, participantId); + expect(firstCheck).toBe(false); + + // Second call (rapid click) - now in queue + vi.mocked(isParticipantInQueue).mockResolvedValueOnce(true); + + const secondCheck = await isParticipantInQueue(teamId, participantId); + expect(secondCheck).toBe(true); + + // Verify the function was called twice + expect(isParticipantInQueue).toHaveBeenCalledTimes(2); + }); + + it('should allow adding different participants to the same queue', async () => { + const teamId = 'team-1'; + const participant1 = 'participant-1'; + const participant2 = 'participant-2'; + const seasonId = 'season-1'; + + // Add first participant + vi.mocked(isParticipantInQueue).mockResolvedValueOnce(false); + vi.mocked(getTeamQueue).mockResolvedValueOnce([]); + vi.mocked(addToQueue).mockResolvedValueOnce({ + id: 'queue-1', + seasonId, + teamId, + participantId: participant1, + queuePosition: 1, + createdAt: new Date(), + updatedAt: new Date(), + }); + + let alreadyInQueue = await isParticipantInQueue(teamId, participant1); + expect(alreadyInQueue).toBe(false); + + await addToQueue({ + seasonId, + teamId, + participantId: participant1, + queuePosition: 1, + }); + + // Add second participant + vi.mocked(isParticipantInQueue).mockResolvedValueOnce(false); + vi.mocked(getTeamQueue).mockResolvedValueOnce([ + { + id: 'queue-1', + seasonId, + teamId, + participantId: participant1, + queuePosition: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + vi.mocked(addToQueue).mockResolvedValueOnce({ + id: 'queue-2', + seasonId, + teamId, + participantId: participant2, + queuePosition: 2, + createdAt: new Date(), + updatedAt: new Date(), + }); + + alreadyInQueue = await isParticipantInQueue(teamId, participant2); + expect(alreadyInQueue).toBe(false); + + const result = await addToQueue({ + seasonId, + teamId, + participantId: participant2, + queuePosition: 2, + }); + + expect(result.participantId).toBe(participant2); + expect(addToQueue).toHaveBeenCalledTimes(2); + }); + + it('should handle the case where participant is in another teams queue', async () => { + const team1Id = 'team-1'; + const team2Id = 'team-2'; + const participantId = 'participant-1'; + const seasonId = 'season-1'; + + // Participant is in team1's queue + vi.mocked(isParticipantInQueue).mockImplementation(async (teamId, pId) => { + return teamId === team1Id && pId === participantId; + }); + + // Check team1 - should be in queue + const inTeam1Queue = await isParticipantInQueue(team1Id, participantId); + expect(inTeam1Queue).toBe(true); + + // Check team2 - should not be in queue + const inTeam2Queue = await isParticipantInQueue(team2Id, participantId); + expect(inTeam2Queue).toBe(false); + + // Should be able to add to team2 + vi.mocked(getTeamQueue).mockResolvedValue([]); + vi.mocked(addToQueue).mockResolvedValue({ + id: 'queue-2', + seasonId, + teamId: team2Id, + participantId, + queuePosition: 1, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await addToQueue({ + seasonId, + teamId: team2Id, + participantId, + queuePosition: 1, + }); + + expect(result.teamId).toBe(team2Id); + }); +}); diff --git a/app/routes/api/queue.add.ts b/app/routes/api/queue.add.ts index aed87e3..1a4738b 100644 --- a/app/routes/api/queue.add.ts +++ b/app/routes/api/queue.add.ts @@ -2,7 +2,7 @@ import { getAuth } from "@clerk/react-router/server"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; -import { addToQueue, getTeamQueue } from "~/models/draft-queue"; +import { addToQueue, getTeamQueue, isParticipantInQueue } from "~/models/draft-queue"; export async function action(args: any) { const { request } = args; @@ -33,6 +33,12 @@ export async function action(args: any) { return Response.json({ error: "Unauthorized" }, { status: 403 }); } + // Check if participant is already in queue + const alreadyInQueue = await isParticipantInQueue(teamId as string, participantId as string); + if (alreadyInQueue) { + return Response.json({ error: "Participant already in queue" }, { status: 400 }); + } + // Get current queue to determine position const currentQueue = await getTeamQueue(teamId as string); const queuePosition = currentQueue.length + 1; diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index a8ad4db..a566361 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -17,6 +17,7 @@ import { AvailableParticipantsSection } from "~/components/draft/AvailablePartic import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks"; import { DraftGridSection } from "~/components/draft/DraftGridSection"; import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility"; +import { toast } from "sonner"; export async function loader(args: any) { const { params } = args; @@ -457,19 +458,57 @@ export default function DraftRoom() { const handleAddToQueue = async (participantId: string) => { if (!userTeam) return; + // Check if participant is already in queue (client-side) + const alreadyInQueue = queue.some((item: any) => item.participantId === participantId); + if (alreadyInQueue) { + toast.error("This participant is already in your queue"); + return; + } + + // Optimistic update - create a temporary queue item + const tempQueueItem = { + id: `temp-${Date.now()}`, // Temporary ID + participantId, + queuePosition: queue.length + 1, + }; + + // Immediately update the UI + setQueue((prev: any) => [...prev, tempQueueItem]); + const formData = new FormData(); formData.append("seasonId", season.id); formData.append("teamId", userTeam.id); formData.append("participantId", participantId); - const response = await fetch("/api/queue/add", { - method: "POST", - body: formData, - }); + try { + const response = await fetch("/api/queue/add", { + method: "POST", + body: formData, + }); - if (response.ok) { - const data = await response.json(); - setQueue((prev: any) => [...prev, data.queueItem]); + if (response.ok) { + const data = await response.json(); + // Replace temp item with real item from server + setQueue((prev: any) => + prev.map((item: any) => + item.id === tempQueueItem.id ? data.queueItem : item + ) + ); + } else { + // Revert the optimistic update on error + setQueue((prev: any) => + prev.filter((item: any) => item.id !== tempQueueItem.id) + ); + + const error = await response.json(); + toast.error(error.error || "Failed to add to queue"); + } + } catch (error) { + // Revert the optimistic update on network error + setQueue((prev: any) => + prev.filter((item: any) => item.id !== tempQueueItem.id) + ); + toast.error("Network error - failed to add to queue"); } }; @@ -491,19 +530,22 @@ export default function DraftRoom() { } }; - const handleClearQueue = async () => { + const handleReorderQueue = async (participantIds: string[]) => { if (!userTeam) return; const formData = new FormData(); formData.append("teamId", userTeam.id); + formData.append("seasonId", season.id); + formData.append("participantIds", JSON.stringify(participantIds)); - const response = await fetch("/api/queue/clear", { + const response = await fetch("/api/queue/reorder", { method: "POST", body: formData, }); if (response.ok) { - setQueue([]); + const data = await response.json(); + setQueue(data.queue); } }; @@ -844,11 +886,11 @@ export default function DraftRoom() { teamId={userTeam.id} isMyTurn={isMyTurn} userAutodraft={userAutodraft} - onClearQueue={handleClearQueue} onRemoveFromQueue={handleRemoveFromQueue} onAutodraftUpdate={(isEnabled, mode) => { setUserAutodraft({ isEnabled, mode }); }} + onReorder={handleReorderQueue} /> } recentPicksSection={ diff --git a/package-lock.json b/package-lock.json index 7b94a07..0360d89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "name": "brackt.com", "dependencies": { "@clerk/react-router": "^2.1.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -1065,6 +1068,59 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "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/@dotenvx/dotenvx": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", diff --git a/package.json b/package.json index f9a3c50..d0338f0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ }, "dependencies": { "@clerk/react-router": "^2.1.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3",