diff --git a/app/components/__tests__/DraftGridSection.test.tsx b/app/components/__tests__/DraftGridSection.test.tsx new file mode 100644 index 0000000..451a5b4 --- /dev/null +++ b/app/components/__tests__/DraftGridSection.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DraftGridSection } from "~/components/draft/DraftGridSection"; + +const draftSlots = [ + { id: "slot-1", draftOrder: 1, team: { id: "team-1", name: "Alpha", logoUrl: null } }, + { id: "slot-2", draftOrder: 2, team: { id: "team-2", name: "Bravo", logoUrl: null } }, +]; + +const ownerMap = { "team-1": "Alpha", "team-2": "Bravo" }; + +const draftGrid = [ + [ + { pickNumber: 1, round: 1, pickInRound: 1, teamId: "team-1", pick: { participant: { name: "Player A" }, sport: { name: "NFL" } } }, + { pickNumber: 2, round: 1, pickInRound: 2, teamId: "team-2" }, + ], +]; + +const baseProps = { + draftSlots, + draftGrid, + currentPick: 2, + teamTimers: {} as Record, + autodraftStatus: {} as Record, + connectedTeams: new Set(["team-1", "team-2"]), + isCommissioner: false, + ownerMap, + seasonStatus: "draft" as const, + draftPaused: false, +}; + +describe("DraftGridSection", () => { + beforeEach(() => vi.clearAllMocks()); + + describe("Team header context menus", () => { + it("shows no header context menu for non-commissioner", () => { + render(); + fireEvent.contextMenu(screen.getAllByText("Alpha")[0]); + expect(screen.queryByText("Adjust Time Bank...")).not.toBeInTheDocument(); + expect(screen.queryByText("Set Autodraft...")).not.toBeInTheDocument(); + }); + + it("shows Adjust Time Bank and Set Autodraft for commissioner on right-click", () => { + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Alpha")[0]); + expect(screen.getByText("Adjust Time Bank...")).toBeInTheDocument(); + expect(screen.getByText("Set Autodraft...")).toBeInTheDocument(); + }); + + it("calls onAdjustTimeBankOpen with correct teamId", async () => { + const user = userEvent.setup(); + const onAdjustTimeBankOpen = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Bravo")[0]); + await user.click(screen.getByText("Adjust Time Bank...")); + expect(onAdjustTimeBankOpen).toHaveBeenCalledWith("team-2"); + }); + + it("calls onSetAutodraftOpen with correct teamId", async () => { + const user = userEvent.setup(); + const onSetAutodraftOpen = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Alpha")[0]); + await user.click(screen.getByText("Set Autodraft...")); + expect(onSetAutodraftOpen).toHaveBeenCalledWith("team-1"); + }); + }); + + describe("Current cell context menu", () => { + it("shows no context menu for non-commissioner on current cell", () => { + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + expect(screen.queryByText("Force Auto Pick")).not.toBeInTheDocument(); + }); + + it("shows Force Auto Pick and Force Manual Pick for commissioner", () => { + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + expect(screen.getByText("Force Auto Pick")).toBeInTheDocument(); + expect(screen.getByText("Force Manual Pick")).toBeInTheDocument(); + }); + + it("shows no force-pick menu when callbacks absent even for commissioner", () => { + render(); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + expect(screen.queryByText("Force Auto Pick")).not.toBeInTheDocument(); + }); + + it("calls onForceAutopick with correct args", async () => { + const user = userEvent.setup(); + const onForceAutopick = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + await user.click(screen.getByText("Force Auto Pick")); + expect(onForceAutopick).toHaveBeenCalledWith(2, "team-2"); + }); + + it("calls onForceManualPickOpen with correct args", async () => { + const user = userEvent.setup(); + const onForceManualPickOpen = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + await user.click(screen.getByText("Force Manual Pick")); + expect(onForceManualPickOpen).toHaveBeenCalledWith(2, "team-2"); + }); + }); + + describe("Picked cell context menu", () => { + it("shows Replace Pick and Roll Back for commissioner on a picked cell", () => { + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + expect(screen.getByText("Replace Pick")).toBeInTheDocument(); + expect(screen.getByText("Roll Back to This Pick")).toBeInTheDocument(); + }); + + it("shows no context menu on picked cell for non-commissioner", () => { + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + expect(screen.queryByText("Replace Pick")).not.toBeInTheDocument(); + }); + + it("calls onReplacePick with correct args", async () => { + const user = userEvent.setup(); + const onReplacePick = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + await user.click(screen.getByText("Replace Pick")); + expect(onReplacePick).toHaveBeenCalledWith(1, "team-1"); + }); + + it("calls onRollbackToPick with correct pickNumber", async () => { + const user = userEvent.setup(); + const onRollbackToPick = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + await user.click(screen.getByText("Roll Back to This Pick")); + expect(onRollbackToPick).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/app/components/__tests__/MiniDraftGrid.test.tsx b/app/components/__tests__/MiniDraftGrid.test.tsx new file mode 100644 index 0000000..9f52e76 --- /dev/null +++ b/app/components/__tests__/MiniDraftGrid.test.tsx @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MiniDraftGrid } from "~/components/draft/MiniDraftGrid"; + +const draftSlots = [ + { id: "slot-1", draftOrder: 1, team: { id: "team-1", name: "Alpha", logoUrl: null } }, + { id: "slot-2", draftOrder: 2, team: { id: "team-2", name: "Bravo", logoUrl: null } }, +]; + +const ownerMap = { "team-1": "Alpha", "team-2": "Bravo" }; + +// Round 1: team-1 pick 1 (filled), team-2 pick 2 (current/empty) +// Round 2 (snake): team-2 pick 3, team-1 pick 4 — both empty +const draftGrid = [ + [ + { pickNumber: 1, round: 1, pickInRound: 1, teamId: "team-1", pick: { participant: { name: "Player A" }, sport: { name: "NFL" } } }, + { pickNumber: 2, round: 1, pickInRound: 2, teamId: "team-2" }, + ], + [ + { pickNumber: 3, round: 2, pickInRound: 1, teamId: "team-2" }, + { pickNumber: 4, round: 2, pickInRound: 2, teamId: "team-1" }, + ], +]; + +const baseProps = { + draftSlots, + draftGrid, + currentPick: 2, + currentRound: 1, + ownerMap, +}; + +describe("MiniDraftGrid", () => { + beforeEach(() => vi.clearAllMocks()); + + describe("Team header layout", () => { + it("renders all team names", () => { + render(); + expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("Bravo").length).toBeGreaterThan(0); + }); + + it("non-commissioner headers carry flex sizing classes", () => { + render(); + const alphaHeader = screen.getAllByText("Alpha")[0].closest(".flex-1"); + expect(alphaHeader).toHaveClass("flex-1", "min-w-20"); + }); + + it("commissioner headers carry flex sizing classes", () => { + render( + + ); + const alphaHeader = screen.getAllByText("Alpha")[0].closest(".flex-1"); + expect(alphaHeader).toHaveClass("flex-1", "min-w-20"); + }); + }); + + describe("Disconnected team styling", () => { + it("applies italic + muted style to disconnected teams when connectedTeams provided", () => { + render( + + ); + const bravoSpan = screen.getAllByText("Bravo")[0]; + expect(bravoSpan).toHaveClass("italic", "text-muted-foreground"); + const alphaSpan = screen.getAllByText("Alpha")[0]; + expect(alphaSpan).not.toHaveClass("italic"); + }); + + it("treats all teams as connected when connectedTeams is not provided", () => { + render(); + expect(screen.getAllByText("Alpha")[0]).not.toHaveClass("italic"); + expect(screen.getAllByText("Bravo")[0]).not.toHaveClass("italic"); + }); + }); + + describe("Team header context menus (commissioner)", () => { + it("shows no context menu when callbacks are absent", () => { + render(); + const alphaHeader = screen.getAllByText("Alpha")[0]; + fireEvent.contextMenu(alphaHeader); + expect(screen.queryByText("Adjust Time Bank...")).not.toBeInTheDocument(); + expect(screen.queryByText("Set Autodraft...")).not.toBeInTheDocument(); + }); + + it("shows Adjust Time Bank and Set Autodraft on right-click when both callbacks provided", () => { + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Alpha")[0]); + expect(screen.getByText("Adjust Time Bank...")).toBeInTheDocument(); + expect(screen.getByText("Set Autodraft...")).toBeInTheDocument(); + }); + + it("shows only Adjust Time Bank when only that callback is provided", () => { + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Alpha")[0]); + expect(screen.getByText("Adjust Time Bank...")).toBeInTheDocument(); + expect(screen.queryByText("Set Autodraft...")).not.toBeInTheDocument(); + }); + + it("calls onAdjustTimeBankOpen with the correct teamId", async () => { + const user = userEvent.setup(); + const onAdjustTimeBankOpen = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Bravo")[0]); + await user.click(screen.getByText("Adjust Time Bank...")); + expect(onAdjustTimeBankOpen).toHaveBeenCalledWith("team-2"); + }); + + it("calls onSetAutodraftOpen with the correct teamId", async () => { + const user = userEvent.setup(); + const onSetAutodraftOpen = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getAllByText("Alpha")[0]); + await user.click(screen.getByText("Set Autodraft...")); + expect(onSetAutodraftOpen).toHaveBeenCalledWith("team-1"); + }); + }); + + describe("Current cell context menu (commissioner)", () => { + it("shows Force Auto Pick and Force Manual Pick on right-click of current cell", () => { + render( + + ); + // Pick 2 is the current cell (team-2, round 1) + const currentCell = screen.getByTitle("Overall Pick #2"); + fireEvent.contextMenu(currentCell); + expect(screen.getByText("Force Auto Pick")).toBeInTheDocument(); + expect(screen.getByText("Force Manual Pick")).toBeInTheDocument(); + }); + + it("calls onForceAutopick with correct args", async () => { + const user = userEvent.setup(); + const onForceAutopick = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + await user.click(screen.getByText("Force Auto Pick")); + expect(onForceAutopick).toHaveBeenCalledWith(2, "team-2"); + }); + + it("calls onForceManualPickOpen with correct args", async () => { + const user = userEvent.setup(); + const onForceManualPickOpen = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + await user.click(screen.getByText("Force Manual Pick")); + expect(onForceManualPickOpen).toHaveBeenCalledWith(2, "team-2"); + }); + + it("shows no force-pick menu when no callbacks provided", () => { + render(); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #2")); + expect(screen.queryByText("Force Auto Pick")).not.toBeInTheDocument(); + expect(screen.queryByText("Force Manual Pick")).not.toBeInTheDocument(); + }); + }); + + describe("Picked cell context menu (commissioner)", () => { + it("shows Replace Pick and Roll Back on right-click of a picked cell", () => { + render( + + ); + // Pick 1 is already picked (team-1) + const pickedCell = screen.getByTitle("Overall Pick #1"); + fireEvent.contextMenu(pickedCell); + expect(screen.getByText("Replace Pick")).toBeInTheDocument(); + expect(screen.getByText("Roll Back to This Pick")).toBeInTheDocument(); + }); + + it("calls onReplacePick with correct args", async () => { + const user = userEvent.setup(); + const onReplacePick = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + await user.click(screen.getByText("Replace Pick")); + expect(onReplacePick).toHaveBeenCalledWith(1, "team-1"); + }); + + it("calls onRollbackToPick with correct pickNumber", async () => { + const user = userEvent.setup(); + const onRollbackToPick = vi.fn(); + render( + + ); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + await user.click(screen.getByText("Roll Back to This Pick")); + expect(onRollbackToPick).toHaveBeenCalledWith(1); + }); + + it("shows no menu on picked cell when no callbacks provided", () => { + render(); + fireEvent.contextMenu(screen.getByTitle("Overall Pick #1")); + expect(screen.queryByText("Replace Pick")).not.toBeInTheDocument(); + }); + }); + + describe("Empty grid", () => { + it("renders nothing when draftGrid is empty", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 544bf7e..1122409 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -58,8 +58,8 @@ interface DraftGridSectionProps { draftPaused?: boolean; onAdjustTimeBankOpen?: (teamId: string) => void; onSetAutodraftOpen?: (teamId: string) => void; - onForceAutopick: (pickNumber: number, teamId: string) => void; - onForceManualPickOpen: (pickNumber: number, teamId: string) => void; + onForceAutopick?: (pickNumber: number, teamId: string) => void; + onForceManualPickOpen?: (pickNumber: number, teamId: string) => void; onReplacePick?: (pickNumber: number, teamId: string) => void; onRollbackToPick?: (pickNumber: number) => void; ownerMap?: Record; @@ -196,7 +196,7 @@ export const DraftGridSection = memo(function DraftGridSection({ const mobileButtons = isCommissioner && ( <> - {!isPicked && isCurrent && ( + {!isPicked && isCurrent && (onForceAutopick || onForceManualPickOpen) && ( - + {onForceAutopick && ( + + )} + {onForceManualPickOpen && ( + + )} )} {mobileSheet?.type === "picked-cell" && ( diff --git a/app/components/draft/MiniDraftGrid.stories.tsx b/app/components/draft/MiniDraftGrid.stories.tsx index da41011..2b4c11e 100644 --- a/app/components/draft/MiniDraftGrid.stories.tsx +++ b/app/components/draft/MiniDraftGrid.stories.tsx @@ -143,3 +143,33 @@ export const EmptyGrid: Story = { ownerMap, }, }; + +export const CommissionerView: Story = { + name: "Commissioner — All Actions Available (right-click headers and cells)", + args: { + draftSlots, + draftGrid: fullGrid, + currentPick: 6, + currentRound: 2, + ownerMap, + connectedTeams: new Set(["team-1", "team-2", "team-3"]), // team-4 disconnected + onAdjustTimeBankOpen: (teamId) => alert(`Adjust time bank for ${teamId}`), + onSetAutodraftOpen: (teamId) => alert(`Set autodraft for ${teamId}`), + onForceAutopick: (pickNumber, teamId) => alert(`Force autopick ${pickNumber} for ${teamId}`), + onForceManualPickOpen: (pickNumber, teamId) => alert(`Force manual pick ${pickNumber} for ${teamId}`), + onReplacePick: (pickNumber, teamId) => alert(`Replace pick ${pickNumber} for ${teamId}`), + onRollbackToPick: (pickNumber) => alert(`Roll back to pick ${pickNumber}`), + }, +}; + +export const DisconnectedTeams: Story = { + name: "Disconnected Teams", + args: { + draftSlots, + draftGrid: fullGrid, + currentPick: 3, + currentRound: 1, + ownerMap, + connectedTeams: new Set(["team-1"]), // team-2 through team-4 disconnected + }, +}; diff --git a/app/components/draft/MiniDraftGrid.tsx b/app/components/draft/MiniDraftGrid.tsx index 6646099..687c5bb 100644 --- a/app/components/draft/MiniDraftGrid.tsx +++ b/app/components/draft/MiniDraftGrid.tsx @@ -48,8 +48,11 @@ export interface MiniDraftGridProps { ownerMap?: Record; teamTimers?: Record; autodraftStatus?: Record; + connectedTeams?: Set; seasonStatus?: SeasonStatus; draftPaused?: boolean; + onAdjustTimeBankOpen?: (teamId: string) => void; + onSetAutodraftOpen?: (teamId: string) => void; onForceAutopick?: (pickNumber: number, teamId: string) => void; onForceManualPickOpen?: (pickNumber: number, teamId: string) => void; onReplacePick?: (pickNumber: number, teamId: string) => void; @@ -64,8 +67,11 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({ ownerMap = {}, teamTimers = {}, autodraftStatus = {}, + connectedTeams, seasonStatus, draftPaused, + onAdjustTimeBankOpen, + onSetAutodraftOpen, onForceAutopick, onForceManualPickOpen, onReplacePick, @@ -136,6 +142,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({ if (draftGrid.length === 0) return null; + const hasHeaderMenu = !!(onAdjustTimeBankOpen || onSetAutodraftOpen); + // h-14 (56px) + gap-1.5 (6px) fallback until first measurement const effectiveRowHeight = rowHeight > 0 ? rowHeight : 56; @@ -151,17 +159,51 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({ {draftSlots.map((slot) => { const teamTime = teamTimers[slot.team.id]; const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false; - return ( -
+ const isConnected = connectedTeams ? connectedTeams.has(slot.team.id) : true; + + const headerContent = ( + <>
{isAutodraft && ( A )} - {ownerMap[slot.team.id] || slot.team.name} + + {ownerMap[slot.team.id] || slot.team.name} +
{formatClockTime(teamTime)}
+ + ); + + if (hasHeaderMenu) { + return ( + + +
+ {headerContent} +
+
+ + {onAdjustTimeBankOpen && ( + onAdjustTimeBankOpen(slot.team.id)}> + Adjust Time Bank... + + )} + {onSetAutodraftOpen && ( + onSetAutodraftOpen(slot.team.id)}> + Set Autodraft... + + )} + +
+ ); + } + + return ( +
+ {headerContent}
); })} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 0171c34..b748530 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -953,13 +953,16 @@ export default function DraftRoom() { ownerMap, teamTimers, autodraftStatus, + connectedTeams, seasonStatus: season.status, draftPaused: isPaused, + onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, + onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onForceAutopick: isCommissioner ? handleForceAutopick : undefined, onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, - }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, season.status, isPaused, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]); + }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, season.status, isPaused, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]); const availableParticipantsSectionProps = useMemo(() => ({ participants: filteredParticipants, @@ -1001,8 +1004,8 @@ export default function DraftRoom() { draftPaused: isPaused, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, - onForceAutopick: handleForceAutopick, - onForceManualPickOpen: handleForceManualPickOpen, + onForceAutopick: isCommissioner ? handleForceAutopick : undefined, + onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, }; diff --git a/app/test/setup.ts b/app/test/setup.ts index 0a9e528..255906e 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -10,6 +10,9 @@ afterEach(() => { // Mock environment variables process.env.NODE_ENV = 'test'; +// jsdom does not implement scrollTo on elements +HTMLElement.prototype.scrollTo = vi.fn(); + // Mock BetterAuth vi.mock('~/lib/auth.server', () => ({ auth: {