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 89a9da9..2aca6d0 100644 --- a/app/components/draft/MiniDraftGrid.tsx +++ b/app/components/draft/MiniDraftGrid.tsx @@ -48,6 +48,7 @@ export interface MiniDraftGridProps { ownerMap?: Record; teamTimers?: Record; autodraftStatus?: Record; + connectedTeams?: Set; seasonStatus?: SeasonStatus; draftPaused?: boolean; onAdjustTimeBankOpen?: (teamId: string) => void; @@ -66,6 +67,7 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({ ownerMap = {}, teamTimers = {}, autodraftStatus = {}, + connectedTeams, seasonStatus, draftPaused, onAdjustTimeBankOpen, @@ -152,51 +154,60 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
{/* Team header */}
- {draftSlots.map((slot) => { - const teamTime = teamTimers[slot.team.id]; - const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false; + {(() => { const hasHeaderMenu = !!(onAdjustTimeBankOpen || onSetAutodraftOpen); + return draftSlots.map((slot) => { + const teamTime = teamTimers[slot.team.id]; + const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false; + const isConnected = connectedTeams ? connectedTeams.has(slot.team.id) : true; - const headerInner = ( -
-
- {isAutodraft && ( - A - )} - {ownerMap[slot.team.id] || slot.team.name} -
-
- {formatClockTime(teamTime)} -
-
- ); - - if (hasHeaderMenu) { - return ( - - -
- {headerInner} -
-
- - {onAdjustTimeBankOpen && ( - onAdjustTimeBankOpen(slot.team.id)}> - Adjust Time Bank... - + const headerContent = ( + <> +
+ {isAutodraft && ( + A )} - {onSetAutodraftOpen && ( - onSetAutodraftOpen(slot.team.id)}> - Set Autodraft... - - )} - - + + {ownerMap[slot.team.id] || slot.team.name} + +
+
+ {formatClockTime(teamTime)} +
+ ); - } - return
{headerInner}
; - })} + if (hasHeaderMenu) { + return ( + + +
+ {headerContent} +
+
+ + {onAdjustTimeBankOpen && ( + onAdjustTimeBankOpen(slot.team.id)}> + Adjust Time Bank... + + )} + {onSetAutodraftOpen && ( + onSetAutodraftOpen(slot.team.id)}> + Set Autodraft... + + )} + +
+ ); + } + + return ( +
+ {headerContent} +
+ ); + }); + })()}
{/* Rows — clipped to 2-row height, slides to reveal new round */} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index e73f1de..b748530 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -953,6 +953,7 @@ export default function DraftRoom() { ownerMap, teamTimers, autodraftStatus, + connectedTeams, seasonStatus: season.status, draftPaused: isPaused, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, @@ -961,7 +962,7 @@ export default function DraftRoom() { onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, - }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, season.status, isPaused, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, 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, @@ -1003,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, };