feat: Implement drag-and-drop functionality for queue items; add duplicate prevention for participants in queue

This commit is contained in:
Chris Parsons 2025-10-25 21:02:16 -07:00
parent c9d3ee73cd
commit 98b84095fd
7 changed files with 528 additions and 49 deletions

View file

@ -1,6 +1,23 @@
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { AutodraftSettings } from "~/components/AutodraftSettings"; 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 { interface QueueSectionProps {
queue: Array<{ queue: Array<{
@ -28,9 +45,78 @@ interface QueueSectionProps {
isEnabled: boolean; isEnabled: boolean;
mode: "next_pick" | "while_on"; mode: "next_pick" | "while_on";
}; };
onClearQueue: () => void;
onRemoveFromQueue: (queueId: string) => void; onRemoveFromQueue: (queueId: string) => void;
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => 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 (
<div
ref={setNodeRef}
style={style}
className="flex items-center justify-between p-2 bg-muted rounded-lg touch-none"
>
<div className="flex items-center gap-2 flex-1" {...attributes} {...listeners}>
<div className="cursor-grab active:cursor-grabbing">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<line x1="5" y1="9" x2="19" y2="9"></line>
<line x1="5" y1="15" x2="19" y2="15"></line>
</svg>
</div>
<Badge variant="default" className="text-xs">{index + 1}</Badge>
<div>
<p className="font-semibold text-sm">{participantName}</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onRemove}
title="Remove from queue"
>
<span className="text-lg">×</span>
</Button>
</div>
);
} }
export function QueueSection({ export function QueueSection({
@ -41,55 +127,64 @@ export function QueueSection({
teamId, teamId,
isMyTurn, isMyTurn,
userAutodraft, userAutodraft,
onClearQueue,
onRemoveFromQueue, onRemoveFromQueue,
onAutodraftUpdate, onAutodraftUpdate,
onReorder,
}: QueueSectionProps) { }: 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 ( return (
<div className="p-4"> <div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Queue ({queue.length})</h3>
{queue.length > 0 && (
<Button variant="outline" size="sm" onClick={onClearQueue}>
Clear
</Button>
)}
</div>
{/* Queue List */} {/* Queue List */}
{queue.length === 0 ? ( {queue.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8"> <p className="text-muted-foreground text-sm text-center py-8">
Click participants to add to your queue Click participants to add to your queue
</p> </p>
) : ( ) : (
<div className="space-y-1.5 mb-4"> <DndContext
{queue.map((item, index) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={item.id} onDragEnd={handleDragEnd}
className="flex items-center justify-between p-2 bg-muted rounded-lg" >
> <SortableContext
<div className="flex items-center gap-2"> items={queue.map((item) => item.id)}
<Badge variant="default" className="text-xs">{index + 1}</Badge> strategy={verticalListSortingStrategy}
<div> >
<p className="font-semibold text-sm"> <div className="space-y-1.5 mb-4">
{availableParticipants.find( {queue.map((item, index) => (
<SortableQueueItem
key={item.id}
item={item}
index={index}
participantName={
availableParticipants.find(
(p) => p.id === item.participantId (p) => p.id === item.participantId
)?.name || "Unknown"} )?.name || "Unknown"
</p> }
</div> onRemove={() => onRemoveFromQueue(item.id)}
</div> />
<Button ))}
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onRemoveFromQueue(item.id)}
title="Remove from queue"
>
<span className="text-lg">×</span>
</Button>
</div> </div>
))} </SortableContext>
</div> </DndContext>
)} )}
{/* Autodraft Settings */} {/* Autodraft Settings */}

View file

@ -27,6 +27,17 @@ export async function getTeamQueue(teamId: string) {
.orderBy(asc(schema.draftQueue.queuePosition)); .orderBy(asc(schema.draftQueue.queuePosition));
} }
export async function isParticipantInQueue(teamId: string, participantId: string): Promise<boolean> {
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) { export async function clearTeamQueue(teamId: string) {
const db = database(); const db = database();
await db.delete(schema.draftQueue).where(eq(schema.draftQueue.teamId, teamId)); await db.delete(schema.draftQueue).where(eq(schema.draftQueue.teamId, teamId));

View file

@ -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);
});
});

View file

@ -2,7 +2,7 @@ import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; 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) { export async function action(args: any) {
const { request } = args; const { request } = args;
@ -33,6 +33,12 @@ export async function action(args: any) {
return Response.json({ error: "Unauthorized" }, { status: 403 }); 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 // Get current queue to determine position
const currentQueue = await getTeamQueue(teamId as string); const currentQueue = await getTeamQueue(teamId as string);
const queuePosition = currentQueue.length + 1; const queuePosition = currentQueue.length + 1;

View file

@ -17,6 +17,7 @@ import { AvailableParticipantsSection } from "~/components/draft/AvailablePartic
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks"; import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { DraftGridSection } from "~/components/draft/DraftGridSection"; import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility"; import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
import { toast } from "sonner";
export async function loader(args: any) { export async function loader(args: any) {
const { params } = args; const { params } = args;
@ -457,19 +458,57 @@ export default function DraftRoom() {
const handleAddToQueue = async (participantId: string) => { const handleAddToQueue = async (participantId: string) => {
if (!userTeam) return; 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(); const formData = new FormData();
formData.append("seasonId", season.id); formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id); formData.append("teamId", userTeam.id);
formData.append("participantId", participantId); formData.append("participantId", participantId);
const response = await fetch("/api/queue/add", { try {
method: "POST", const response = await fetch("/api/queue/add", {
body: formData, method: "POST",
}); body: formData,
});
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setQueue((prev: any) => [...prev, data.queueItem]); // 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; if (!userTeam) return;
const formData = new FormData(); const formData = new FormData();
formData.append("teamId", userTeam.id); 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", method: "POST",
body: formData, body: formData,
}); });
if (response.ok) { if (response.ok) {
setQueue([]); const data = await response.json();
setQueue(data.queue);
} }
}; };
@ -844,11 +886,11 @@ export default function DraftRoom() {
teamId={userTeam.id} teamId={userTeam.id}
isMyTurn={isMyTurn} isMyTurn={isMyTurn}
userAutodraft={userAutodraft} userAutodraft={userAutodraft}
onClearQueue={handleClearQueue}
onRemoveFromQueue={handleRemoveFromQueue} onRemoveFromQueue={handleRemoveFromQueue}
onAutodraftUpdate={(isEnabled, mode) => { onAutodraftUpdate={(isEnabled, mode) => {
setUserAutodraft({ isEnabled, mode }); setUserAutodraft({ isEnabled, mode });
}} }}
onReorder={handleReorderQueue}
/> />
} }
recentPicksSection={ recentPicksSection={

56
package-lock.json generated
View file

@ -7,6 +7,9 @@
"name": "brackt.com", "name": "brackt.com",
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0", "@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-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@ -1065,6 +1068,59 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT" "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": { "node_modules/@dotenvx/dotenvx": {
"version": "1.51.0", "version": "1.51.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",

View file

@ -22,6 +22,9 @@
}, },
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0", "@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-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",